Skip to content

feat(auth): add GitHub App server-to-server authentication for stdio#2797

Merged
SamMorrowDrums merged 2 commits into
mainfrom
sammorrowdrums-github-app-s2s-auth
Jul 22, 2026
Merged

feat(auth): add GitHub App server-to-server authentication for stdio#2797
SamMorrowDrums merged 2 commits into
mainfrom
sammorrowdrums-github-app-s2s-auth

Conversation

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator

Summary

Adds non-interactive GitHub App installation authentication to the stdio server, so headless deployments (CI, background agents, the Claude Agent SDK, a self-hosted image spawned by Copilot's cloud agent) can authenticate with no browser, device code, or elicitation.

This is the outstanding follow-up tracked in #1333: stdio OAuth (#2704) shipped the interactive user-to-server flows, but PEM-based server-to-server (s2s) auth was still needed to remove the human-in-the-loop requirement.

Builds on the prior art in #2562 (thanks @pyama86) — same goal, reworked for a tighter security posture and a stdlib-only implementation.

How it works

internal/githubapp signs a short-lived RS256 JWT with the app's private key, exchanges it for an installation access token, and refreshes it transparently ~5 minutes before expiry. The token is held in memory only. It exposes a Provider whose AccessToken() mirrors oauth.Manager, so it slots into the existing BearerAuthTransport token provider with no new transport code. Only the standard library and golang.org/x/oauth2 are used (no new dependencies).

Safe key injection

  • --app-id / GITHUB_APP_ID, --app-installation-id / GITHUB_APP_INSTALLATION_ID
  • --app-private-key-path / GITHUB_APP_PRIVATE_KEY_PATHpreferred: mount as a secret file; keeps the key off argv and out of the environment
  • GITHUB_APP_PRIVATE_KEY — inline fallback for env-only secret stores

There is intentionally no flag for the key contents, which would otherwise leak via the process command line. App auth is mutually exclusive with a PAT and with OAuth login.

Why stdio only (by design)

This is not available for the http server, and that's deliberate. An HTTP server authenticating with a server-wide app identity would let any client that can reach its endpoint act as the app with full permissions — turning a network-reachable port into ambient, unauthenticated access to the whole installation. The http server keeps requiring per-request Authorization tokens so every caller's identity stays explicit. (Distinct from #2236, which is about per-request multi-identity tokens.)

Security

Per popular demand, but dangerous: this injects a long-lived, high-privilege credential alongside an AI agent, and minted tokens act across every repo the app is installed on. A loud startup warning and a dedicated docs page (docs/github-app-auth.md, with Docker + Kubernetes secret-mount examples, least-privilege guidance, and the stdio-only rationale) make this explicit. Not recommended without an independent security review.

Test plan

  • internal/githubapp unit tests: PKCS#1/fix: added reason argument to get_me function #8 key parsing, JWT round-trip + claim/lifetime verification, installation-token fetch (success / error status / expiry) against httptest, caching, early refresh, and error-once logging.
  • internal/ghmcp mutual-exclusivity guard extended to cover all three modes (token / OAuth / App).
  • script/lint (0 issues) and script/test (race) green; generate-docs produces no drift.

Closes #1333


🤖 Generated with GitHub Copilot CLI

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

@SamMorrowDrums
SamMorrowDrums requested a review from a team as a code owner June 29, 2026 22:06
Copilot AI review requested due to automatic review settings June 29, 2026 22:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds GitHub App server-to-server (installation token) authentication to the stdio GitHub MCP server, enabling fully headless deployments to authenticate without interactive OAuth/PAT provisioning.

Changes:

  • Introduces internal/githubapp to mint RS256 app JWTs, exchange them for installation tokens, and refresh/cache tokens via an AccessToken() provider.
  • Wires stdio config to support an App-auth mode with explicit mutual exclusivity vs PAT and OAuth, plus a loud startup warning about credential risk.
  • Adds documentation for GitHub App auth usage patterns (Docker/Kubernetes) and updates README / OAuth docs to point to it.
Show a summary per file
File Description
README.md Adds a headless-deployment pointer to the new GitHub App auth docs and security warning.
internal/githubapp/githubapp.go New GitHub App s2s auth implementation (JWT minting, installation-token fetch, caching/refresh provider).
internal/githubapp/githubapp_test.go Unit tests for key parsing, JWT correctness, token fetching, caching/refresh, and error logging behavior.
internal/ghmcp/server.go Adds AppAuth config, enforces auth-mode exclusivity, wires provider into token transport, and logs a startup warning.
internal/ghmcp/oauth_test.go Extends exclusivity tests to cover PAT vs OAuth vs App auth combinations.
docs/oauth-login.md Notes that OAuth is still human-mediated and points headless users to GitHub App auth.
docs/github-app-auth.md New end-to-end documentation for stdio GitHub App auth, including security guidance and deployment examples.
cmd/github-mcp-server/main.go Adds stdio flags/env handling for GitHub App auth, host→REST base resolution, and exclusivity checks.

Review details

  • Files reviewed: 8/8 changed files
  • Comments generated: 5
  • Review effort level: Low

Comment thread internal/githubapp/githubapp.go Outdated
Comment on lines +67 to +69
// AppID is the GitHub App's App ID or client ID; it becomes the JWT issuer
// (iss). Both forms are accepted by GitHub.
AppID string

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't correct. GitHub's own docs state the iss claim can be either the client ID or the App ID, and actually recommend the client ID:

iss (Issuer): The client ID or application ID of your GitHub App... Use of the client ID is recommended.

Source: Generating a JWT for a GitHub App — the Ruby example there even uses iss: "YOUR_CLIENT_ID". Accepting both is intentional, so no change here.

Comment thread docs/github-app-auth.md Outdated
Comment on lines +95 to +99
3. Note three values:
- the **App ID** (or the app's **client ID** — either works as the JWT issuer),
- the **installation ID** (visible in the installation's settings URL, or via
the [installations API](https://docs.github.com/en/rest/apps/apps#list-installations-for-the-authenticated-app)),
- the path to the **private key** `.pem`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the code thread: GitHub's JWT docs say the iss claim is "the client ID or application ID of your GitHub App" and that "use of the client ID is recommended." The prerequisites section is accurate as written.

Comment thread docs/github-app-auth.md Outdated
Comment on lines +109 to +112
| `--app-id` | `GITHUB_APP_ID` | GitHub App ID or client ID. Becomes the JWT issuer. |
| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation ID whose token is minted. |
| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM file. **Preferred** way to supply the key. |
| _(no flag)_ | `GITHUB_APP_PRIVATE_KEY` | The PEM contents inline. Use only where a file can't be mounted. Literal `\n` sequences are accepted so the key can live in a single-line variable. |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config table is correct. Per GitHub's JWT docs, the JWT issuer may be the client ID or the App ID, and the client ID is the recommended value. --app-id / GITHUB_APP_ID accepting either is intended.

Comment on lines +68 to +70
if token == "" && !appAuthRequested && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH), or pass --oauth-client-id to log in via OAuth")
}
Comment thread cmd/github-mcp-server/main.go Outdated
Comment on lines +266 to +273
// stdio-specific GitHub App (server-to-server) flags. Provide an app ID,
// installation ID, and private key to authenticate non-interactively — no
// browser, device code, or elicitation. Intended for headless deployments.
// The private key itself has no flag (only GITHUB_APP_PRIVATE_KEY): a flag
// would place the key in the process arguments. Prefer the key file path.
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text is intentional. GitHub's JWT docs accept either the client ID or the App ID as the iss, and recommend the client ID — so "App ID or client ID" is the right guidance.

@pyama86

pyama86 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for the excellent proposal. Even with HTTP, it is possible to listen with a high degree of isolation in environments like Kubernetes. Therefore, instead of restricting it to stdio, I would like you to leave the decision up to the user.

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator Author

@pyama86 I understand it can be done safely, but I do have to be careful about what GitHub provides because users may use it in inappropriate contexts also, and the risk with http version is massively higher. With port binding to 0.0.0.0 a user could easily expose themselves to even website direct access. You are of course welcome to do whatever you like with a fork, while we iron out what's possible here.

I will not even merge this until I discuss with security/auth folks in GitHub also.

@go-kazuhiko-yamashita

Copy link
Copy Markdown

@SamMorrowDrums
I also agree that we should be cautious about GitHub's responsibility as a platform and the baseline it needs to maintain.
By the way, the other PR of mine that got merged #2655 was intended to make it listen on 127.0.0.1. I completely respect your concerns, so I'm happy to wait for the outcome.

@SamMorrowDrums

Copy link
Copy Markdown
Collaborator Author

@SamMorrowDrums

I also agree that we should be cautious about GitHub's responsibility as a platform and the baseline it needs to maintain.

By the way, the other PR of mine that got merged #2655 was intended to make it listen on 127.0.0.1. I completely respect your concerns, so I'm happy to wait for the outcome.

I was super happy to receive and merge that PR! Makes sense in this context especially. 🙏

@SamMorrowDrums
SamMorrowDrums force-pushed the sammorrowdrums-github-app-s2s-auth branch from f2bcfcf to 021c071 Compare July 15, 2026 13:29
@SamMorrowDrums
SamMorrowDrums changed the base branch from main to sammorrowdrums-go-sdk-upgrade-fix July 15, 2026 13:29
Base automatically changed from sammorrowdrums-go-sdk-upgrade-fix to main July 15, 2026 13:35
SamMorrowDrums and others added 2 commits July 22, 2026 15:20
Add non-interactive GitHub App installation authentication to the stdio
server, so headless deployments (CI, Kubernetes, background agents) can
authenticate without a browser, device code, or elicitation. This is the
outstanding follow-up tracked in #1333: OAuth login shipped the interactive
user-to-server flows, but PEM-based server-to-server auth was still needed to
remove the interactive requirement.

The new internal/githubapp package signs a short-lived RS256 JWT with the
app's private key, exchanges it for an installation access token, and refreshes
it transparently before expiry. It exposes a Provider whose AccessToken method
mirrors oauth.Manager so it plugs into the existing BearerAuthTransport token
provider. Only the standard library and golang.org/x/oauth2 are used.

The private key is injected safely: a file path (GITHUB_APP_PRIVATE_KEY_PATH,
preferred — mountable as a secret and kept off argv and out of the environment)
or an inline GITHUB_APP_PRIVATE_KEY env var. There is intentionally no flag for
the key contents, which would otherwise leak via the process command line.

App auth is mutually exclusive with a PAT and with OAuth login. A loud startup
warning and a dedicated docs page (docs/github-app-auth.md, with Docker and
Kubernetes examples) cover the security considerations: this injects a
high-privilege credential alongside the agent and is not recommended without an
independent security review.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep PEM loading and installation-token provider construction at the CLI leaf, then pass a generic refreshing token provider through the existing HTTP transports. Rebase the feature onto current main and keep the HTTP command unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 646357dd-c89f-4973-9a5c-e6c5fc18818c
@SamMorrowDrums
SamMorrowDrums force-pushed the sammorrowdrums-github-app-s2s-auth branch from 021c071 to b7dee3e Compare July 22, 2026 13:36

@RossTarrant RossTarrant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@SamMorrowDrums
SamMorrowDrums merged commit ea4e396 into main Jul 22, 2026
19 checks passed
@SamMorrowDrums
SamMorrowDrums deleted the sammorrowdrums-github-app-s2s-auth branch July 22, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support GitHub App authentication with client credentials

6 participants